● Zion Boggan
repos/Sightline/frontend/src/app/vehicles/[id]/page.tsx
zionboggan.com ↗
104 lines · tsx
History for this file →
1
"use client";
2
import { useEffect, useState } from "react";
3
import { useParams } from "next/navigation";
4
import Link from "next/link";
5
import { api, CoTravel, MovementSummary } from "@/lib/api";
6
 
7
export default function VehicleProfile() {
8
  const { id } = useParams<{ id: string }>();
9
  const plate = decodeURIComponent(id).toUpperCase();
10
  const [m, setM] = useState<MovementSummary | null>(null);
11
  const [cotravel, setCotravel] = useState<CoTravel[]>([]);
12
  const [err, setErr] = useState<string | null>(null);
13
 
14
  useEffect(() => {
15
    api.movement(plate)
16
      .then(setM)
17
      .catch((e) => setErr(e.status === 401 ? "Sign in to view this vehicle." : "No reads for this plate."));
18
    api.cotravel(plate).then((r) => setCotravel(r.candidates)).catch(() => {});
19
  }, [plate]);
20
 
21
  if (err) return <div className="badge danger">{err}</div>;
22
  if (!m) return <div className="muted">Loading…</div>;
23
 
24
  const maxHour = Math.max(1, ...Object.values(m.by_hour));
25
 
26
  return (
27
    <div>
28
      <h1>Vehicle {m.plate_norm}</h1>
29
      <div className="grid">
30
        <div className="card"><div className="muted">Total reads</div><div className="kpi">{m.total_reads}</div></div>
31
        <div className="card"><div className="muted">Distinct cameras</div><div className="kpi">{m.distinct_cameras}</div></div>
32
        <div className="card"><div className="muted">First seen</div><div>{fmt(m.first_seen)}</div></div>
33
        <div className="card"><div className="muted">Last seen</div><div>{fmt(m.last_seen)}</div></div>
34
      </div>
35
 
36
      {m.anomalies.length > 0 && (
37
        <div className="card" style={{ borderColor: "var(--danger)", marginTop: 14 }}>
38
          <strong className="badge danger">⚠ {m.anomalies.length} movement anomaly</strong>
39
          {m.anomalies.map((a, i) => (
40
            <div key={i} className="row" style={{ justifyContent: "space-between", marginTop: 6 }}>
41
              <span className="muted">{a.reason.replace(/_/g, " ")}</span>
42
              <span className="badge danger">{Math.round(a.speed_kmh)} km/h</span>
43
            </div>
44
          ))}
45
        </div>
46
      )}
47
 
48
      <h2>Activity by hour of day</h2>
49
      <div className="card">
50
        <div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 120 }}>
51
          {Array.from({ length: 24 }, (_, h) => {
52
            const v = m.by_hour[String(h)] || 0;
53
            return (
54
              <div key={h} style={{ flex: 1, textAlign: "center" }}>
55
                <div title={`${h}:00 - ${v} reads`} style={{
56
                  height: `${(v / maxHour) * 100}px`, background: v ? "var(--accent)" : "var(--border)",
57
                  borderRadius: 3, minHeight: 2 }} />
58
                <div className="muted" style={{ fontSize: 9 }}>{h}</div>
59
              </div>
60
            );
61
          })}
62
        </div>
63
      </div>
64
 
65
      <h2>Route legs</h2>
66
      <div className="card">
67
        <table>
68
          <thead><tr><th>From</th><th>To</th><th>Elapsed</th><th>Distance</th><th>Speed</th></tr></thead>
69
          <tbody>
70
            {m.route.map((l, i) => (
71
              <tr key={i}>
72
                <td className="muted">{l.from?.slice(0, 8)}</td>
73
                <td className="muted">{l.to?.slice(0, 8)}</td>
74
                <td>{Math.round(l.elapsed_s)}s</td>
75
                <td>{l.distance_km ? l.distance_km.toFixed(1) + " km" : "-"}</td>
76
                <td>{l.speed_kmh ? <span className={"badge " + (l.implausible ? "danger" : "ok")}>{Math.round(l.speed_kmh)}</span> : "-"}</td>
77
              </tr>
78
            ))}
79
            {!m.route.length && <tr><td colSpan={5} className="muted">Single sighting - no route.</td></tr>}
80
          </tbody>
81
        </table>
82
      </div>
83
 
84
      <h2>Possible co-travelers</h2>
85
      <p className="muted">Vehicles repeatedly seen alongside {m.plate_norm} within the co-travel window - candidate associates.</p>
86
      <div className="card">
87
        <table>
88
          <thead><tr><th>Plate</th><th>Shared sightings</th></tr></thead>
89
          <tbody>
90
            {cotravel.map((c) => (
91
              <tr key={c.plate_norm}>
92
                <td><Link href={`/vehicles/${c.plate_norm}`}>{c.plate_norm}</Link></td>
93
                <td><span className="badge">{c.shared_sightings}</span></td>
94
              </tr>
95
            ))}
96
            {!cotravel.length && <tr><td colSpan={2} className="muted">No co-travel candidates.</td></tr>}
97
          </tbody>
98
        </table>
99
      </div>
100
    </div>
101
  );
102
}
103
 
104
function fmt(s: string | null) { return s ? new Date(s).toLocaleString() : "-"; }